Skip to content

向量检索与 ANN 索引 ​

标签
AI/agent/检索
字数
5896 字
阅读时间
23 分钟

11-RAG 检索增强 讲的是整条生成链路,这一篇只讲其中检索侧的核心机制:向量之间的远近怎么算、为什么必须用近似检索、主流索引各自在做什么、top-k 怎么取。

先纠正一个定位:向量检索是 RAG 检索侧的一种方法,不是全部。 检索侧至少还有这几种,生产上常混用:

方法依据强项弱项
稠密向量检索语义相似度同义改写、跨表述匹配专有名词、精确 ID 匹配差
稀疏检索(BM25 等)词项匹配公司名、财务指标、会计期间这类逐字出现的术语不会语义泛化
图检索实体与关系多跳关系推理要有图谱,构建成本高
结构化查询SQL / 元数据精确、可聚合只能问出已知模式
混合检索多路融合覆盖面最广需要融合策略(见 11-RAG 检索增强 的 RRF)

所以「RAG 效果不好」时,第一件该判断的事是:我的查询适合哪一路? 拿专有名词去问纯向量库,本来就不该期待好结果。

距离怎么算 ​

向量之间「近」的定义决定了索引怎么建,三种主流度量:

度量含义典型场景
L2 欧氏距离两点间直线距离:各维差值平方和再开根号视觉特征、度量学习
余弦相似度只看方向夹角,不看模长;方向完全一致为 1文本语义检索——语义更关心「话题方向」而非向量长短
内积(点积)余弦的未归一化版本推荐系统——模长可以顺手编码「热度」

一个实用的工程冷知识:向量先做 L2 归一化,内积就等于余弦相似度。所以 FAISS 里用 IndexFlatIP 配归一化向量,算的其实就是余弦。

不过最终应以具体模型的模型卡和训练目标为准——有些嵌入模型是按内积训练的,硬套余弦反而错。

为什么不能用 B+ 树 ​

「给向量建个索引」的第一反应是 B+ 树或哈希表,但两者都不行:

结构为什么不行
B+ 树它依赖数据有稳定的「大小顺序」——数字、字符串是一维的,能排序。向量是上千维浮点数组,很难定义一个既自然又能服务相似度查询的全序关系
普通哈希依赖精确等值命中,而两个浮点向量完全相等的概率约等于零,无法表达「相近」
—高维空间中的维度灾难会让许多传统低维索引结构逐渐失去效率

LSH(局部敏感哈希)是专门为近似相似检索设计的另一类 ANN 方法,和普通哈希不是一回事。

退到暴力搜索(Brute-Force)呢?它对,但代价是 O(N × dim):

python
def brute_force_search(vectors, query, k=10):
    scores = vectors @ query          # 归一化后内积 = 余弦相似度
    return np.argsort(-scores)[:k]    # 分数降序,取前 K

算笔账:100 万条 1024 维向量,一轮查询约 20 亿次浮点运算;光把这 4GB 向量从内存读一遍就要吃掉可观的内存带宽,放磁盘上更是灾难。100 个并发用户就把 CPU 排到天亮。

数据量小的时候暴力搜索就是最优解——结果 100% 精确,连索引都不用建。ANN 是数据量上来之后才必须付的代价。

ANN:用少量召回换数量级速度 ​

ANN(Approximate Nearest Neighbor,近似最近邻) 不保证返回全局最优的 K 个近邻,而是靠预建索引只扫描一小部分向量,返回「足够接近」的结果。

典型的交易是:recall@10 保持在 95–99%,把延迟从秒级压到毫秒级。

recall@K 的常见误解 ​

看到「95% 召回率」就以为「5% 的查询会彻底失败」,是错的。recall@K 描述的是整体结果的平均重合程度,不是「有多少比例的查询完全失效」。

对 RAG 来说,边界位置的少量交换通常影响有限——反正下游模型还要再读一遍上下文。但真正重要的答案没进候选集,仍会影响最终回答。

反过来,人脸支付、重复内容去重这类「漏一个就是事故」的业务,要单独针对 recall@1 设计指标,不能拿 recall@10 的 95% 自我安慰。

三大件的分工(这个区分最容易被搞混) ​

   ┌─────────────────────────────────────────────────┐
   │ 问题一:搜哪些向量?(路由)                       │
   │   ├─ IVF    聚类分区派:切 nlist 个簇,只探 nprobe 个 │
   │   └─ HNSW   图导航派:沿多层近邻图下钻              │
   └─────────────────────────────────────────────────┘
   ┌─────────────────────────────────────────────────┐
   │ 问题二:每条向量怎么用更少的字节表示?(压缩)        │
   │   └─ PQ     把高维向量切段,每段用聚类中心编号替代    │
   └─────────────────────────────────────────────────┘

PQ 严格来说是一种量化压缩方法,不负责路由。
它通常与 IVF 或 HNSW 组合使用(IVF-PQ、HNSW-PQ),而不是单独承担检索。
三者的关系:两种路由机制各选一个,压缩按需要叠加。

PQ 严格来说是一种量化压缩方法,不负责路由。 它通常与 IVF 或 HNSW 组合使用(IVF-PQ、HNSW-PQ),而不是单独承担检索。三者可以叠加。

HNSW:图导航 ​

HNSW(Hierarchical Navigable Small World,分层可导航小世界图) 是目前最广泛部署的 ANN 索引——pgvector、Qdrant、Weaviate、Milvus、FAISS 以及多数托管向量库的默认选项。

结构:三层路网 ​

把向量组织成一张多层近邻图,层数越高节点越少、连接越长:

HNSW 把向量组织成一张多层近邻图:层数越高节点越少、连接越长

   顶层    高铁网     全国几十个站,站间一跳上千公里
     │                └─ 查询从这里进入,先粗定位到大区域
     ▼
   中层    城际地铁   上百个站,送进目标片区
     │
     ▼
   底层    街道       所有向量都在这一层,负责最后一百米的精确定位
                      └─ 到底层后维护一个候选集做更充分的搜索

查询的走向
   从顶层某个入口出发 ──▶ 反复查看邻居、移动到更接近目标的节点
                     ──▶ 该层无法继续改善时下沉一层
                     ──▶ 重复到最底层 ──▶ 返回最近的 K 个

复杂度约 O(log N),而不是 O(N)。

三个参数
   M               每节点每层最多连几条边(部分实现底层放宽到 2M)   16–32
   efConstruction  建图时的候选集宽度                            100–200
   efSearch        查询时的候选集宽度                            32–128,不小于 K
   └─ efSearch 是运行时的召回/延迟旋钮,不需要重建索引就能调 ——
      HNSW 最实用的一点

查询时从顶层某个入口出发,在每层反复查看邻居、移动到更接近目标的节点,直到该层无法继续改善,再带着当前结果下沉到下一层。到底层后维护一个候选集做更充分的搜索,最后返回最近的 K 个。

复杂度约 O(log N),而不是 O(N)。

三个参数 ​

参数作用典型值调大的收益与代价
M每个节点每层最多连几条边(部分实现的底层会放宽到 2M)16–32(也有 16–64 的说法)边越密召回越高;内存和建图时间同步上涨
efConstruction建图时的候选集宽度100–200图的质量更好,建图更慢
efSearch查询时的候选集宽度32–128,工程上一般不小于 K召回更高,延迟更大

efSearch 是运行时的召回/延迟旋钮——不需要重建索引就能调。这是 HNSW 最实用的一点。

实测的三档配置(1M × 1536 维) ​

配置(M / efSearch)Recall@10查询延迟吞吐
快(16 / 32)93.4%1.1 ms3,850 QPS
均衡(32 / 64)97.8%2.2 ms2,100 QPS
高精度(64 / 128)99.4%4.6 ms1,050 QPS

三档配置在召回、延迟、吞吐上的实测权衡(1M × 1536 维)。

             M / efSearch   Recall@10    延迟         吞吐
快            16 / 32        93.4%      1.1 ms     3,850 QPS
均衡          32 / 64        97.8%      2.2 ms     2,100 QPS
高精度        64 / 128       99.4%      4.6 ms     1,050 QPS

   召回从 93.4% 提到 99.4%(+6 个百分点)的代价是延迟 ×4、吞吐掉到 27%。
   └─ 最后那 1.6 个百分点(97.8 → 99.4)单独占掉了接近一半的延迟预算。

两条必须知道的代价
   内存是它的价签
     M = 16 + 768 维 FP32 ──▶ 每个向量约 3.4 KB(向量 + 图边 + 开销)
        │
        └─ 1 亿向量就是 340 GB DRAM,还没算应用层开销
           └─ 经验规律:HNSW 在 1000 万到 5000 万向量以下是正确答案,
              到十亿级就是错误答案
   删除是墓碑
     删节点会破坏图结构,实现上通常只置一个 DELETE_MARK 位、查询时跳过
        │
        └─ 长期下来小世界性质退化,recall 会从 98% 掉到 85% 甚至更低
           高变更(churn)场景需要定期重建索引

两个必须知道的代价 ​

内存是它的价签。 图结构要常驻内存:向量存一份,每个节点几十条边的邻居编号又是一份。M=16 + 768 维 FP32,每个向量约 3.4 KB(向量 + 图边 + 开销);1 亿向量就是 340 GB DRAM,还没算应用层开销。

所以有一条经验规律:HNSW 在 1000 万到 5000 万向量以下是正确答案,到十亿级就是错误答案。

删除是墓碑。 删节点会破坏图结构,所以实现上通常只置一个 DELETE_MARK 位、查询时跳过。长期下来小世界性质退化,recall 会从 98% 掉到 85% 甚至更低——高变更(churn)场景需要定期重建索引。

IVF:聚类分区 ​

IVF(Inverted File Index) 先用 K-Means 把向量空间切成 nlist 个簇,每个簇一个质心;查询时只探测离查询向量最近的 nprobe 个簇,簇内再算精确距离。

类比快递分拨:包裹不会被送到全国每个网点问「这是谁的」,而是先到目标城市的分拨中心,再层层下沉。

省多少:1000 万条向量、nlist=4096、nprobe=32 时,平均扫描约 32/4096 ≈ 0.8%——候选集从一千万压到八万。

两个旋钮的脾气 ​

参数作用经验取值
nlist簇数√N 到 4√N 作为初始试探范围,但不是硬公式。簇越多每簇越小、扫描量可能下降,但训练与质心搜索成本上升,边界召回更敏感
nprobe探测簇数nlist/32 到 nlist/8 对应 90–95% 召回。FAISS 的默认 nprobe=1 在生产里几乎总是错的

软肋:聚类边界是硬切割 ​

查询向量恰好站在两个簇的界线上、真正的最近邻躺在隔壁簇里,而你没探测那个簇——它就根本进不了这次查询的候选集。这不是「排得靠后」,是「完全没出现」,重排阶段也救不回来。

数据分布严重不均、某些簇过大时效果也会打折。另外 K-Means 要先训练,数据持续涌入、分布漂移后通常需要重训或调整,增量更新不如 HNSW 方便。

PQ:压缩,不是索引 ​

PQ(Product Quantization,乘积量化) 把高维向量切成若干段,每段独立用聚类中心编号替代原始浮点值。

三步 ​

① 切段
   把 D 维向量均匀切成 M 段(128 维切 16 段,每段 8 维)
        │
        ▼
② 子空间聚类
   对每段子向量单独跑 K-Means,通常聚 256 个中心
   └─ 256 = 2⁸,所以每段编号正好用 1 字节
        │
        ▼
③ 编码
   每段用 1 字节编号(0–255)替代原来的 8 个浮点数

压缩比
   128 维切 16 段      512 字节  ──▶ 16 字节   (32 倍)
   1536 维切 64 段    6144 字节  ──▶ 64 字节   (96 倍)
   4096 维切 64 段      16 KB   ──▶ 64 字节

代价:编号是中心的近似,量化误差天生存在。
所以 PQ 的产出只配当「粗排分」,不能当最终结论。

压缩比:128 维从 512 字节 → 16 字节(32 倍);1536 维切 64 段,6144 字节 → 64 字节(96 倍);4096 维切 64 段,16 KB → 64 字节。

ADC:为什么它能快到这种程度 ​

压成编号之后,距离怎么算?这是 PQ 最巧妙的部分——ADC(Asymmetric Distance Computation,非对称距离计算):

  1. 查询向量不压缩,照样切成 M 段
  2. 预先算好每段查询子向量到该段 256 个中心的距离,拼成一张 M × 256 的查找表
  3. 库里的向量只存着 M 个编号,近似距离 = 查 M 次表、加起来

浮点乘加变成了查表加法——这就是亿级向量粗排能跑在普通机器上的原因。

两个实现细节 ​

残差编码:实际的 IVF-PQ 里,常见做法是先减去所属簇的质心、对残差向量做 PQ 编码,而不是去直接量化原始向量,这能进一步降低量化误差。

OPQ(Optimized PQ):量化前先旋转向量空间以最小化重构误差。需要离线训练,但同压缩比下召回能提升 2–5%。

代价:编号是中心的近似,量化误差天生存在。所以 PQ 的产出只配当「粗排分」,不能当最终结论——下一节马上要用到这句。

DiskANN:单机十亿级 ​

DiskANN 的思路是分层用存储:Vamana 图与全精度向量放 SSD,只把 PQ 压缩向量放 RAM 供遍历使用。

  • 构建用 α-relaxed pruning(α > 1),产生比 HNSW 的严格多样性启发式更长距离的边,减少图的跳数
  • 一次查询:先在 RAM 里对 PQ 向量做 beam search 找出候选,再对它们的全精度向量发起 5–10 次 SSD 随机读做重排
  • 瓶颈是 SSD 带宽,不是容量

SIFT1B 基准(10 亿 × 128 维)上,16 核 / 64 GB RAM / 一块消费级 SSD 能跑 5000+ QPS、平均延迟 < 3 ms、95%+ 1-recall@1。同样语料用 HNSW 需要 640+ GB RAM。

ScaNN ​

Google 2020 年的 ScaNN 用的是各向异性量化(anisotropic quantization)——按各维度对内积排名的贡献加权,而不是像 PQ 那样对各维度一视同仁。发布时在 ann-benchmarks 上超过了其他 11 个调优过的库(同等召回下约 2 倍 QPS)。

怎么选 ​

判据Flat(暴力)IVFHNSWPQ
数据量< 100K100K–10M100K–50M1M–1B+
召回100%90–99%95–99.9%80–95%
内存高高很高低
建索引无中等慢中等
查询速度规模化后很慢快很快快
增量插入支持需重建原生支持需重建

多数场景的默认答案:向量数少于 1000 万时选 HNSW——召回最高、查询速度有竞争力、且不需要训练步骤。

三类特殊情形:

  • 内存不够 → 加 PQ 压缩(IVF-PQ、HNSW-PQ),代价是召回
  • 十亿级 → DiskANN 那类磁盘图方案,或 IVF-PQ
  • 要求绝对精确 → Flat,但只在 10 万向量以下才现实

四种索引的对照,判据以数据量为入口。

             Flat        IVF          HNSW         PQ
数据量       < 100K      100K–10M     100K–50M     1M–1B+
召回         100%        90–99%       95–99.9%     80–95%
内存         高          高           很高         低
建索引       无          中等         慢           中等
查询速度     规模化后慢   快           很快         快
增量插入     支持        需重建       原生支持     需重建

   └─ 多数场景的默认答案:向量数少于 1000 万时选 HNSW ——
      召回最高、查询速度有竞争力,而且不需要训练步骤。

三类特殊情形
   内存不够      ──▶ 加 PQ 压缩(IVF-PQ、HNSW-PQ),代价是召回
   十亿级        ──▶ DiskANN 那类磁盘图方案,或 IVF-PQ
   要求绝对精确   ──▶ Flat,但只在 10 万向量以下才现实

怎么测自己的 recall
   ① 用 Flat 索引对一批代表性 query 跑出真值(精确最近邻)
   ② 再拿 ANN 索引跑同样的 query
   ③ recall@K = 真近邻出现在 ANN 结果里的比例
      └─ 95% 表示平均每条查询的真实 Top-K 里有 9.5 个被找回
   └─ 别信任何脱离数据的「默认参数」

怎么测自己的 recall ​

用 Flat 索引对一批代表性 query 跑出真值(精确最近邻),再拿 ANN 索引跑同样的 query,算 recall@K = 真近邻出现在 ANN 结果里的比例。95% 表示平均每条查询的真实 Top-K 里有 9.5 个被找回。

别信任何脱离数据的「默认参数」。

top-k 怎么取 ​

top-k 不是一个可以拍脑袋定的常数,它受四个因素牵制:

因素影响
下游要几条最终送进模型的是 3–5 条(见 11-RAG 检索增强 的两阶段规则),所以 k 要按「召回多少给重排」定,不是按「送几条」定
重排器的容量重排 100 条以上的候选很少划算——这是 k 的上界来源
efSearch / nprobe 的下界HNSW 的 efSearch 不应小于 k,否则候选集比要的还小;IVF 同理
延迟预算加 k 是线性加成本:更多候选 = 更多重排推理 + 更多 token

工程上的取法,按顺序做三件事:

  1. 定最终条数 n(由模型的上下文预算和答案粒度决定,通常 3–5)
  2. 定召回放大倍数,取 k = n × 放大倍数。放大倍数按「重排能捞回多少」定——检索是粗的、重排是精的,所以要给它足够的选择余地。扩到 4 倍是个常见的起点(HelloAgents 的 candidate_pool_multiplier 默认就是 4)
  3. 设一个绝对下限,防止 n 很小时候选池太小。同样在 HelloAgents 里是 max(top_k × multiplier, 20)

另外两个和 top-k 配套的机制:

分数阈值:在 top-k 之外再加一个 score_threshold,低于它的结果直接丢掉。它在硬约束场景下有特殊价值——如果连最高分都不够高,正确行为是拒答而不是硬答(对应 11-RAG 检索增强 里的置信度门控)。

去重合并:多路检索或查询扩展会产生重复,合并时按 document id 保留最高分(RRF 里也是这个逻辑),最后再截到 k。

top-k 受四个因素牵制,取法按三步走。

四个牵制因素
   下游要几条             最终送进模型的是 3–5 条
                          └─ 所以 k 要按「召回多少给重排」定,不是按「送几条」定
   重排器的容量           重排 100 条以上很少划算 —— 这是 k 的上界来源
   efSearch / nprobe      HNSW 的 efSearch 不应小于 k,否则候选集比要的还小
                          IVF 的 nprobe 同理
   延迟预算               加 k 是线性加成本:更多候选 = 更多重排推理 + 更多 token

取法(按顺序)
   ① 定最终条数 n              由模型的上下文预算与答案粒度决定,通常 3–5
        │
        ▼
   ② 定召回放大倍数 k = n × 倍数  给重排留选择余地,扩到 4 倍是个常见的起点
        │
        ▼
   ③ 设一个绝对下限             防止 n 很小时候选池太小:max(k, 20)

两个配套机制
   分数阈值 score_threshold   低于它直接丢掉。硬约束场景下有特殊价值 ——
                              连最高分都不够高时,正确行为是拒答而不是硬答
   去重合并                   多路检索或查询扩展会产生重复,
                              按 document id 保留最高分,最后再截到 k

一条查询的完整旅程 ​

把前面的零件拼起来(以「带标量过滤 + IVFPQ」为例):

① 向量化        用户提问 → embedding 模型 → 查询向量
        │
        ▼
② 标量过滤      用元数据剔掉不符合条件的候选("只查 2026 年之后的")
        │       └─ 最容易被忽略、但生产里天天用的一步
        ▼
③ ANN 粗筛      IVF 探 nprobe 个簇 / HNSW 沿图下钻
        │       从百万级捞出几百到几万个「大概近」的
        ▼
④ PQ 粗排       用查表距离快速打分排序(便宜但粗糙)
        │
        ▼
⑤ 原始向量精排  取前几百名捞回原始 FP32 向量,算精确距离重排
        │
        ▼
   送进重排器 / prompt

反直觉的一点:在需要精排的方案里,近似索引通常只负责海选,
决赛还是原始浮点向量之间的比较。
   └─ 由此推出一条部署约束:精排意味着系统还得保存原始向量,
      或者能从外部存储回捞。量化索引必须额外考虑原始向量放在哪 ——
      这也是 DiskANN 要在 SSD 上留一份全精度向量的原因。

反直觉的一点:在需要精排的方案里,近似索引通常只负责海选,决赛还是原始浮点向量之间的比较。

由此推出一个部署上的约束:精排意味着系统还得保存原始向量,或者能从外部存储回捞。如果索引本身存的就是原始向量(Flat、HNSW、IVF-FLAT),向量本身就是结果;如果是量化索引,就必须额外考虑原始向量的存放位置——这也是 DiskANN 要在 SSD 上留一份全精度向量的原因。

第 ② 步是最容易被忽略、但生产里天天用的:带过滤的向量检索(filtered search)在实现上有三种取舍——先过滤再搜(过滤后可能太小)、边搜边过滤(实现复杂)、先搜后过滤再扩大候选集(要多取一些)。选哪种取决于过滤条件的选择性有多强。

相关 ​

参考 ​

贡献者 ​

文件历史 ​